# Copyright 2007, 2008 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


# TODO(epg): --with-revprop support, but disallow gvn:* setting with
# that mechanism.  Add options to set some gvn: properties
# (e.g. --reviewers => gvn:reviewers --bug => gvn:bug).

import codecs
import os
import posixpath
import sys

import gvn.changebranch
import gvn.cmdline
import gvn.errors
import gvn.platform
import gvn.util
import gvn.wc


helptext__gvn_change = """change: Manage a changebranch.
usage: 1. change [-c CHANGE] [--add] PATH...
       2. change -c CHANGE --remove PATH...
       3. change -c CHANGE --delete

  1. Add PATHs to changebranch, creating it if it does not exist,
     and generating a random changebranch name if none is provided.
  2. Remove PATHs from changebranch.
  3. Delete changebranch.
"""


def _InTargets(path, targets):
  for target in targets:
    if gvn.util.IsChild(path, target):
      return True
  return False

def _Targets(operands, path):
  """Return the list of paths to add to or remove from the changebranch."""
  if len(operands) <= 1:
    # We have only one operand, or none at all (implicit .), so path
    # is our only path to add/remove.
    targets = [path]
  else:
    # Add/remove all operands.
    targets = [posixpath.join(path, x) for x in operands]
  return targets

def _PathsToAdd(path_status, old_changed_paths, operands, path):
  new_changed_paths = set(old_changed_paths)
  targets = _Targets(operands, path)
  # Add all locally modified paths starting with any path in targets.
  for path in path_status:
    if _InTargets(path, targets):
      new_changed_paths.add(path)
  return new_changed_paths

def _PathsToRemove(old_changed_paths, operands, path):
  new_changed_paths = set(old_changed_paths)
  targets = _Targets(operands, path)
  # Remove all paths in old_changed_paths starting with any path in targets
  # (e.g. for targets=['foo'] changed_paths=['food', 'foo', 'foo/bar'],
  # remove 'foo' and 'foo/bar' but not 'food').
  for path in old_changed_paths:
    if _InTargets(path, targets):
      new_changed_paths.remove(path)
  return new_changed_paths


_MESSAGE_DIVIDER = '--All lines above this line become the description--'
_PATHS_IN = 'Paths in this changebranch'
_PATHS_OUT = 'Paths modified but not in any changebranch:'
def ChangeEditor(ctx, cb, desc, action, path_status, changed_paths,
                 editor, encoding):
  """Return (desc, {path: svn_wc_status2_t}) for paths to be changebranched.

  Run the user's editor on a form representing the state of the
  working copy, with desc in first section, cb.changed_paths in
  second, and non-changedbranched modified paths in a third.  Return
  the change description and changed paths from the edited form.

  Arguments:
  ctx           -- gvn.cmdline.Context
  cb            -- gvn.changebranch.ChangeBranch
  desc          -- change description (utf-8 str)
  action        -- action being taken ('add', 'edit', or 'remove')
  path_status   -- dict of all modified paths in the working copy, with
                   svn_wc_status2_t for values
  changed_paths -- paths to be changebranched (utf-8 str keys)
  editor        -- gvn.util.Editor for the user's editor
  encoding      -- user's encoding
  """

  if desc is None:
    desc = ''

  form = [
    desc.rstrip(),
    _MESSAGE_DIVIDER,
    '',
    'Move files between the next two sections to add or remove them from',
    'the changebranch.',
    '',
    '%s (%s):' % (_PATHS_IN, cb.name),
    ]

  #################################################################
  # Write paths already in or being added to the changebranch.

  # Map display paths back to wc paths.
  display_to_wc = {}

  path_to_change_name = ctx.wc.MapPathToChangeName()
  for wc_path in sorted(changed_paths):
    rel_path = ctx.DisplayPath(wc_path)
    display_to_wc[rel_path] = wc_path

    if (cb.name in ctx.wc.change_state
        and wc_path in ctx.wc.change_state[cb.name].paths):
      # Changebranch exists and this path is already in it.
      moved = ' '
    elif wc_path in path_to_change_name:
      # Path is in some other changebranch; skip it.
      continue
    else:
      moved = '*'
    form.append('%s%s    %s' % (moved,
                                gvn.wc.ActionCode(path_status[wc_path]),
                                rel_path))

  #################################################################
  # Write paths out of or being removed from the changebranch.

  form.append('')
  form.append(_PATHS_OUT)

  for wc_path in sorted(path_status.iterkeys()):
    if wc_path in changed_paths:
      # Already on changebranch, so already in first section, skip.
      continue
    if wc_path in path_to_change_name:
      if path_to_change_name[wc_path] != cb.name:
        # Path is in some other changebranch; skip it.
        continue
      if action != 'remove':
        # Already on or being added to changebranch, so it's in the
        # first section; skip it.
        continue
    rel_path = ctx.DisplayPath(wc_path)
    display_to_wc[rel_path] = wc_path

    if wc_path in ctx.wc.change_state[cb.name].paths:
      moved = '*'
    else:
      moved = ' '
    form.append('%s%s    %s' % (moved,
                                gvn.wc.ActionCode(path_status[wc_path]),
                                rel_path))
  form.append('')

  answer = editor.Edit('\n'.join(form), encoding, tmp_prefix='gvnchange.')
  if answer is None:
    return (None, None)

  state = 0
  result_paths = {}
  desc = []
  for line in answer.splitlines():
    if state == 0:
      if line == _MESSAGE_DIVIDER:
        state = 1
      else:
        desc.append(line)
    elif state == 1 and line.startswith(_PATHS_IN):
      state = 2
    elif state == 2:
      if line == '':
        continue
      elif line == _PATHS_OUT:
        break
      # TODO(epg): Wrong place to be encoding to utf-8; gvn.wc should only
      # return unicode paths, and should .encode all its input; that's not
      # where we are today, though, so have to do this for now.
      path = display_to_wc[line[6:].encode('utf-8')]
      result_paths[path] = path_status[path]

  # Restore final newline.
  desc.append('')

  return (u'\n'.join(desc), result_paths)


def Handle_GvnChange(ctx, editor_class=gvn.util.Editor):

  # TODO(epg): This description block is repeated a few places, i
  # think, along with popping up an editor for the log message.
  # *After* we implement full editor support, see what can be
  # refactored.  Doing it before would just be premature, as we don't
  # know what that's going to look like
  description = None
  if ctx.options.file is not None:
    description = codecs.open(ctx.options.file,
                              encoding=ctx.encoding).read()
  elif ctx.options.message is not None:
    description = ctx.options.message.decode(ctx.encoding)
  # TODO(epg): force-log

  action = None
  if ctx.options.add:
    # Add files on the command line into the change branch.
    action = 'add'
  if ctx.options.delete:
    # Delete the changebranch.
    if action is not None:
      raise gvn.errors.BadOptions("cannot specify both 'delete' and '%s'"
                                   % (action,))
    action = 'delete'
  if ctx.options.remove:
    # Remove files on the command line from the change branch.
    if action is not None:
      raise gvn.errors.BadOptions("cannot specify both 'remove' and '%s'"
                                   % (action,))
    action = 'remove'
  if action is None:
    # Default to edit mode (which becomes add mode if creating a new
    # changebranch; see below.
    action = 'edit'

  change_name = ctx.options.change
  if change_name is None:
    if action != 'add' and action != 'edit':
      # Must specify a change name on which to operate.
      raise gvn.errors.BadOptions("must provide a change name (via '-c')")

    # Generate a unique, random (and short) change name.
    change_name = gvn.changebranch.UniqueChangeName(ctx.project,
                      ctx.project.repository.username,
                      gvn.changebranch.RandomBranchName(),
                      ctx.pool)

  (username, cname, revision) = gvn.util.ParseChangeName(change_name)
  if ctx.wc is None:
    snap_revision = None
  else:
    try:
      snap_revision = ctx.wc.change_state[cname].snap_revision
    except gvn.errors.NoChangeBranchInWC:
      snap_revision = None
  if snap_revision is None:
    cb = gvn.changebranch.ChangeBranch(ctx.config, ctx.project,
                                       cname, username, peg_revision=revision)
  else:
    cb = gvn.changebranch.ChangeBranch(ctx.config, ctx.project,
                                       cname, username,
                                       snap_revision=snap_revision)
  if action == 'delete':
    cb.Delete(description=description, wc=ctx.wc, pool=ctx.pool)
    return cb

  # Find old changed paths, for the form.
  old_changed_paths = set()
  creating = cname not in ctx.wc.change_state
  if creating:
    # Creating new changebranch.
    action = 'add'
    ctx.ValidateChangeName(cb)
  else:
    for path in ctx.wc.change_state[cname].paths:
      if not gvn.util.IsChild(path, ctx.path_dir):
        raise gvn.errors.ChangeBranchOutsideTarget(path, ctx.path_dir)
      old_changed_paths.add(path)

  ctx.wc.Open([ctx.path])

  path_status = {}
  def status_cb(target, status):
    if gvn.wc.IsModified(status):
      path_status[ctx.wc.RelativePath(target)] = status
  # Use path_dir here so we can get status of everything from target
  # directory down, to put in the non-changebranched section.
  # XXX rename show_children to show_add_children
  ctx.wc.Status([ctx.path_dir], recursive=not ctx.options.non_recursive,
                get_all=False, no_ignore=False, show_children=False,
                callback=status_cb)

  if len(path_status) == 0:
    if creating:
      # Asked to create changebranch, but no modified files, so exit
      # silently like 'svn commit'.
      # TODO(before-submit): verify that mail exits silently, too
      return cb
    # Changebranch exists, but none of the files are any longer modified.
    # TODO(before-submit): clear out change state and wc.Save
    # warn user that he might want to delete the cb
    return cb

  if action == 'add':
    new_changed_paths = _PathsToAdd(path_status, old_changed_paths,
                                    ctx.operands, ctx.path)
  elif action == 'remove':
    new_changed_paths = _PathsToRemove(old_changed_paths,
                                       ctx.operands, ctx.path)
  else:
    new_changed_paths = old_changed_paths

  if description is None and not creating:
    # Note: this fetches the description from the repository.
    description = cb.description

  editor = None
  try:
    if ctx.options.non_interactive:
      # Set paths to status of changed_paths.
      new_path_status = {}
      for path in new_changed_paths:
        try:
          new_path_status[path] = path_status[path]
        except KeyError:
          # User specified unmodified file; ignore it.
          pass
    else:
      # Get paths from the user via $EDITOR.
      editor = editor_class(ctx.config.editor_command)
      (description, new_path_status) = ChangeEditor(ctx, cb, description,
                                                    action, path_status,
                                                    new_changed_paths,
                                                    editor, ctx.encoding)
      if description is None:
        if not ctx.options.quiet:
          ctx.Notify('cancelled\n')
        editor.Done()
        return None

    if len(new_path_status) == 0:
      raise gvn.errors.BadOperands("no pathnames specified!")

    if creating:
      method = cb.Create
      kwargs = {}
    else:
      method = cb.Snapshot
      # TODO(before-submit): After finished testing, set minimal=False for
      # the next release; that way even old gvn can review the
      # changebranches, it just ignores the state file.  In the release
      # after that, go back to minimal=True unless --full-snapshot.
      kwargs = {'minimal': not ctx.options.full_snapshot}
    try:
      method(ctx.wc, new_path_status, ctx.pool, description=description,
             **kwargs)
    except gvn.errors.OutOfDateParent, e:
      e.branch_path = ctx.DisplayPath(e.branch_path)
      raise
    if editor is not None:
      editor.Done()
  finally:
    if editor is not None and not editor.IsDone():
      sys.stderr.write('Your change form was left in a temporary file:\n'
                       '%s\n' % (editor.tmpfile,))

  # TODO: test gvn mail with each of the different ways this returns
  return cb


def wrap_Handle_GvnChange(ctx):
  if Handle_GvnChange(ctx) is None:
    return 1
  return 0


options = gvn.cmdline.AuthOptions(gvn.cmdline.LogOptions(
  ['add', 'delete', 'remove', 'change', 'force-change', 'full-snapshot',
   'project', 'quiet']))
gvn.cmdline.AddCommand('change', wrap_Handle_GvnChange, helptext__gvn_change,
                       options, {'change': 'changebranch ARG'})
